/* 
 *  Copyright 2012 CodeMagi, Inc.
 * 
 *  Licensed under the Apache License, Version 2.0 (the "License");
 *  you may not use this file except in compliance with the License.
 *  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 */
package com.codemagi.servlets.search;

import com.codemagi.database.DataSourceBean2;
import com.codemagi.login.AccessException;
import com.codemagi.login.LoginException;
import com.codemagi.login.model.*;
import com.codemagi.servlets.AppException;
import com.codemagi.servlets.ServletUtils;
import com.codemagi.servlets.search.beans.*;
import com.codemagi.util.*;
import java.io.*;
import java.util.*;
import javax.servlet.*;
import javax.servlet.http.*;
import org.apache.commons.collections.map.ReferenceMap;
import org.apache.log4j.*;
import org.apache.poi.hssf.usermodel.HSSFCell;
import org.apache.poi.hssf.usermodel.HSSFRichTextString;
import org.apache.poi.hssf.usermodel.HSSFRow;
import org.apache.poi.hssf.usermodel.HSSFSheet;
import org.apache.poi.hssf.usermodel.HSSFWorkbook;

/**
 * The SearchController class is a framework for creating customized searches. 
 * Extend this class to implement the <code>dispatchSearch</code> and 
 * <code>dispatchSort</code> methods for your specific database schema. Also 
 * extend the <code>SearchBean</code> class to include the specific fields 
 * you wish to search. 
 *     
 * This is the controller for search querying that can be pushed to any jsp and be rendered in many different ways (MVC).
 * Search results page at /web/search/results.jsp which is set in the controller (protected String VIEW_RESULTS).
 *
 *  @version 1.0
 *  @author August Detlefsen for CodeMagi, Inc.
 */
public abstract class SearchController extends com.codemagi.servlets.BaseServlet {

    //set to true to view debug output
    Logger log = Logger.getLogger(this.getClass());

    //MEMBERS
    private static DataSourceBean2 dataBean    = null;
    protected static Map queryResults          = new ReferenceMap();
    protected int resultsPerPage               = 20;
    protected int pagesToDisplay            = 10;
    protected String beanName                  = "searchBean";
    protected String beanClass                 = "com.codemagi.servlets.search.beans.SearchBean";

    protected static boolean loginRequired     = false;
    protected boolean searchByDefault          = true;

    protected long cacheTimeout = -1;
    protected ReferenceMap queryTimes = new ReferenceMap();

    /**
     * A key:value HashMap of sort clauses in the form of short name:SQL clause. This can be 
     * overridden in subclasses to allow for sorting by clicking on column headers
     */
    protected HashMap sortClauses              = new HashMap();

    /** 
     * A Hashmap of custom column names to use for downloadable reports. This can be overridden 
     * in subclasses to allow for custom column names
     * 
     * @see setColumnNames()
     */
    protected LinkedHashMap<String,Integer> columnNames = null;

    //VIEWS specific to SearchController
    protected String VIEW_SEARCH               = "/search/index.jsp";
    protected String VIEW_RESULTS              = "/search/results.jsp";
    protected String VIEW_MAIN                 = "/search/index.jsp";
    protected String VIEW_DOWNLOAD             = "DOWNLOAD";
    protected String VIEW_PDF                  = "PDF";

    //MESSAGES 
    protected static final String MSG_NO_SEARCH            = "You must enter some search terms first.";

    /**
     * Initializes the servlet when it is loaded by the
     * servlet engine.
     *
     * @param config the configuration as <code>ServletConfig</code>
     *
     * @throws ServletException if initialization fails.
     */
    public void init(ServletConfig config)
        throws ServletException {

        super.init(config);

	log.debug(this.getClass().getName() + " -init");

	//get the database init parameter and create data bean
	String jndiConnect = config.getInitParameter("jndi.database");
        log.debug("About to create DataSourceBean2 using: " + jndiConnect);
        generateDataBean(jndiConnect);

	//determine the number of results to show per page
	try {
	    log.debug("*** trying to load resultsPerPage");
	    Integer pResultsPerPage = convertInteger( config.getInitParameter("resultsPerPage"), true );
	    if (pResultsPerPage != null) resultsPerPage = Math.max(pResultsPerPage.intValue(), 1);  //no negatives, no zero
	    log.debug("*** loading resultsPerPage complete");
	} catch (AppException ae) {
	    log.debug("", ae);
	}
	log.debug("*** done loading resultsPerPage=" + resultsPerPage);

	//determine the number of PagesToDisplay
	try {
	    log.debug("*** trying to load PagesToDisplay");
	    Integer pPagesToDisplay = convertInteger( config.getInitParameter("pagesToDisplay"), true );
	    if (pPagesToDisplay != null) pagesToDisplay = Math.max(pPagesToDisplay.intValue(), 1); 
	    //no negatives, no zero
		
	    log.debug("*** loading pagesToDisplay complete");
	} catch (AppException ae) {
	    log.debug("", ae);
	}
        log.debug("*** done loading pagesToDisplay=" + pagesToDisplay);


	//determine if login is required to search
	loginRequired = Boolean.valueOf( config.getInitParameter("loginRequired") ).booleanValue();
	log.debug("Login required for search: " + loginRequired);

	//if we should search by default (with no parameters) if no other action is specified
	String pSearchByDefault = config.getInitParameter("searchByDefault");
	if (!Utils.isEmpty(pSearchByDefault)) searchByDefault = Boolean.valueOf( pSearchByDefault ).booleanValue();
	log.debug("Search by default: " + searchByDefault);

        String sCacheTimeout = config.getInitParameter("cacheTimeout");
        if ( !Utils.isEmpty(sCacheTimeout) ) {
            try {
                cacheTimeout = Long.decode(sCacheTimeout);
                log.debug("Cache Timeout set to: " + cacheTimeout + " msec");
            } catch (NumberFormatException nfe) {
                log.warn("WARNING: Invalid number format supplied for cacheTimeout parameter");
            }
        }

	//place a reference to this servlet in the application context
	application.setAttribute(config.getServletName(), this);
    }


    /**
     * Handles the incoming requests.
     * <p>
     * This implementation ensures authenticated session
     * existence, retrieves the <code>acton</code>
     * and <code>todo</code> parameters, and dispatches all
     * valid actions to the target dispatchers.
     * <p>
     * The flow of the process is described in the related
     * documentation.
     * <p>
     * Application related errors/exceptions are handled
     * by forwarding the request to an error page, or the actual page
     * in case of an inlined error.
     *
     * @param req a reference to the actual <code>HttpServletRequest</code>
     *        instance.
     * @param res a reference to the actual <code>HttpServletResponse</code>
     *        instance.
     *
     * @throws ServletException if servlet related operations fail.
     * @throws IOException if i/o operations fail.
     */
    public void service(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {

	String action = request.getParameter("acton");
	log.debug("SearchController: ACTON: " + action);

	//view request attributes by seeting the log4j configuration for this servlet to debug
	log.debug("REQUEST: " + request);
	log.debug("SESSION: " + request.getSession());
	ServletUtils.logRequestParameters(request);
	ServletUtils.logRequestAttributes(request);
	ServletUtils.logSessionAttributes(request);

	//next page to forward to will depend on the outcome of the request dispatchers
	String nextPage = VIEW_MAIN;

	try {
	    if (loginRequired) checkLogin( request );

	    //dispatch request actions to the proper method
	    if ("search".equals(action)) {
		nextPage = dispatchSearch(request,response);

	    } else if ("next".equals(action)) {
		nextPage = dispatchNext(request,response);

	    } else if ("previous".equals(action)) {
		nextPage = dispatchPrevious(request,response);

	    } else if ("page".equals(action)) {
		nextPage = dispatchPage(request,response);

	    } else if ("sort".equals(action)) {
		nextPage = dispatchSort(request);

	    } else if ("download".equals(action)) {
		nextPage = dispatchDownload(request, response);
		if (VIEW_DOWNLOAD.equals(nextPage)) return;

	    } else if ("downloadExcel".equals(action)) {
		nextPage = dispatchDownloadExcel(request, response);
		if (VIEW_DOWNLOAD.equals(nextPage)) return;

	    } else if ("pdf".equals(action)) {
		nextPage = dispatchPdf(request, response);
		if (VIEW_PDF.equals(nextPage)) return;

	    } else if ("reset".equals(action)) {
		nextPage = dispatchReset(request);

	    } else {
		if (searchByDefault) nextPage = dispatchSearch(request,response);

	    }

	} catch (AccessException ae) {
	    nextPage = handleAccessException(request, response, ae);

        } catch (LoginException le) {
	    request.setAttribute("nextPage", request.getContextPath() + request.getServletPath());
	    nextPage = handleLoginException(request, response, le);

        }

        //forward      
        log.debug("Forwarding to: " + nextPage);

        if (!VIEW_NONE.equals(nextPage)) {
	    RequestDispatcher dispatcher = getServletContext().getRequestDispatcher(nextPage);
	    dispatcher.forward(request, response);
	}

	return;
    }


    /**
     * Dispatches <code>Search</code> actions.
     *
     * Override this method to read search params from the request, generate your query and forward to the results page
     */
    protected abstract String dispatchSearch(HttpServletRequest request, HttpServletResponse response)
        throws LoginException, AccessException;


    /**
     * Generates the search query and places it in the provided bean
     */
    protected abstract void generateQuery(ISearchBean bean) throws AppException;


    /**
     * Dispatches <code>pdf</code> actions. Returns result set as a PDF.
     * 
     * This is a no-op in this class.
     *
     * @see SearchControllerIText
     * @see SearchControllerFOP
     */
    protected String dispatchPdf(HttpServletRequest request, HttpServletResponse response)
        throws LoginException, AccessException {

	return VIEW_PDF;
    }


    /**                                                                                                                   
     * Dispatches <code>reset</code> actions: Puts a fresh copy of the searchbean into the user's session
     */
    protected String dispatchReset(HttpServletRequest request) throws LoginException {

        log.debug("***** dispatchReset *****");

        //next page if operation is successful                                                                            
        String nextPage = VIEW_SEARCH;

	try {
	    //get session                                                                                                      
	    HttpSession session = request.getSession();
	    
	    //get a fresh SearchBean
	    ISearchBean bean = (ISearchBean)(Class.forName(beanClass).newInstance());
	    log.debug("ISearchBean: " + bean);

	    //set the search bean into the session                                           
	    session.setAttribute(beanName, bean);

	} catch (Exception e) {
	    log.debug("", e);

	    request.setAttribute("error_message", MSG_UNKNOWN_ERROR);
	}

        return nextPage;
    }


    /**
     * Dispatches <code>download</code> actions. Returns result set as a tab-delimited fils. 
     */
    protected String dispatchDownload(HttpServletRequest request, HttpServletResponse response) 
	throws LoginException, AccessException {

        log.debug("***** dispatchDownload *****");

        //next page if operation is successful                                                                            
        String nextPage = VIEW_RESULTS;

	try {
	    //get session                                                                                                      
	    HttpSession session = request.getSession();
	    
	    //get SearchBean from session                                                                                  
	    ISearchBean bean = (ISearchBean)session.getAttribute(beanName);
	    if (bean == null) throw new AppException(MSG_NO_SEARCH);

	    GridList contracts = getResults(bean);

	    String tabData = contracts.toString("\t");

	    response.setContentType("text/tab-delimited");
	    response.setHeader("Content-Disposition", "attachment; filename=results.csv;");

	    java.io.PrintWriter output = response.getWriter();
	    output.print(tabData);
	    output.flush();

	    nextPage = VIEW_DOWNLOAD;
	    
	} catch (AppException e) {
	    log.debug("", e);
	    
	    request.setAttribute("error_message", e.getMessage());

	} catch (IOException ie) {
	    log.debug("", ie);

	    request.setAttribute("error_message", MSG_UNKNOWN_ERROR);
	}
	
        return nextPage;
    }
    

    /**
     * Dispatches <code>downloadExcel</code> actions. Returns result set as an Excel fils. 
     */
    protected String dispatchDownloadExcel(HttpServletRequest request, HttpServletResponse response) 
	throws LoginException, AccessException {

        log.debug("***** dispatchDownloadExcel *****");

        //next page if operation is successful                                                                            
        String nextPage = VIEW_RESULTS;

	//output stream to receive results
	OutputStream out = null;

	try {
	    //get session                                                                                                      
	    HttpSession session = request.getSession();
	    
	    //get SearchBean from session                                                                                  
	    ISearchBean bean = (ISearchBean)session.getAttribute(beanName);
	    if (bean == null) throw new AppException(MSG_NO_SEARCH);

	    GridList results = getResults(bean);

	    //if we have custom column headers for the results, add them now
	    setColumnNames(bean, results);

	    //the spreadsheet to hold the output
	    HSSFWorkbook wb = new HSSFWorkbook();
	    HSSFSheet sheet = wb.createSheet();

	    //a style to apply to cells
	    //	    HSSFCellStyle style = wb.createCellStyle();

	    //add the header rows to the results
	    Set<String> headers = results.getColumnNames().keySet();
	    HSSFRow headerRow   = sheet.createRow(0);
	    short headerColumn  = 0;

	    headerRow.setHeightInPoints(12);

	    for (String header : headers) {
		HSSFCell cell = headerRow.createCell(headerColumn);

		cell.setCellValue( new HSSFRichTextString(header) );
		
		headerColumn++;
	    }

	    //loop through the rows in the result set and add them to the spreadsheet
	    java.util.List<java.util.List> rows = results.getRows();
	    int rowNum          = 1;
	    for (java.util.List dataRow : rows) {
		HSSFRow row = sheet.createRow(rowNum);
		short column  = 0;

		row.setHeightInPoints(12);

		for (Object data : dataRow) {
		    HSSFCell cell = row.createCell(column);
		    column++;
		    
		    if (data == null) continue;

		    if (data instanceof String) {
			cell.setCellValue( new HSSFRichTextString((String)data) );

		    } else if (data instanceof Integer) {
			cell.setCellValue( ((Integer)data).doubleValue() );

		    } else if (data instanceof Float) {
			cell.setCellValue( ((Float)data).doubleValue() );

		    } else if (data instanceof Double) {
			cell.setCellValue( (Double)data );

		    } else if (data instanceof Date) {
			cell.setCellValue( (Date)data );

		    } else {
			cell.setCellValue( new HSSFRichTextString(data.toString()) );

		    }

		}

		rowNum++;
	    }

	    response.setContentType("application/msexcel");
	    response.setHeader("Content-Disposition", "attachment; filename=results.xls;");
	    response.setHeader("Expires", "0");
	    response.setHeader("Pragma", "public");
	    response.setHeader("Cache-Control", "must-revalidate, post-check=0, pre-check=0");

	    out = response.getOutputStream();
	    wb.write(out);
	    out.flush();

	    nextPage = VIEW_DOWNLOAD;
	    
	} catch (AppException e) {
	    log.debug("", e);
	    
	    request.setAttribute("error_message", e.getMessage());

	} catch (IOException ie) {
	    log.debug("", ie);

	    request.setAttribute("error_message", MSG_UNKNOWN_ERROR);

	} finally {
	    closeStream(out);

	}
	
        return nextPage;
    }
    

    /**                                                                                                                   
     * Dispatches <code>sort</code> actions.                                                                     
     */
    protected String dispatchSort(HttpServletRequest request) throws LoginException {

        log.debug("***** dispatchSort *****");

        //next page if operation is successful                                                                            
        String nextPage = VIEW_RESULTS;

	try {
	    //get session                                                                                                      
	    HttpSession session = request.getSession();
	    
	    //get SearchBean from session                                                                                  
	    ISearchBean bean = (ISearchBean)session.getAttribute(beanName);
	    if (bean == null) bean = (ISearchBean)(Class.forName(beanClass).newInstance());
	    
	    //get sort param                                                                                            
	    String sortBy   = request.getParameter("sortBy");
	    
	    //if the user clicked the same sort by again, toggle the direction of the sort
	    if ( !Utils.isEmpty(sortBy) && sortBy.equals(bean.getSortBy()) ) {
		bean.setSortAscending( !bean.getSortAscending() );
	    }

	    //set new values in the bean                                                                                       
	    bean.setSortBy(sortBy);

	    //set the user into the bean in case it is needed by generateQuery
	    IUser user = getUser(request);
	    log.debug("USER: " + user);
	    bean.setUser(user);
	    
	    //generate a new query with the updated sort value                                                  
	    generateQuery(bean);

	    //set the search bean into the session                                           
	    session.setAttribute(beanName, bean);

	} catch (Exception e) {
	    log.debug("", e);

	    request.setAttribute("error_message", MSG_UNKNOWN_ERROR);
	}

        return nextPage;
    }



    /**
     * Dispatches <code>Next</code> actions.
     */
    protected String dispatchNext(HttpServletRequest request, HttpServletResponse response)
        throws LoginException, AccessException {

        log.debug("***** dispatchNext *****");

        //next page if operation is successful
        String nextPage = VIEW_RESULTS;

        try {
	    //get session     
	    HttpSession session = request.getSession();
	    
	    //get searchBean from session, check for validity
	    log.debug(this.getClass().getName() + " Retrieving ISearchBean: " + beanName + " from session");
	    ISearchBean bean = (ISearchBean)session.getAttribute(beanName);
	    log.debug("ISearchBean: " + bean);
	    if (bean == null || Utils.isEmpty(bean.getQuery()) ) throw new AppException(MSG_NO_SEARCH);

	    //set the next page
	    GridList results = getResults(bean);
	    int numPages     = results.getNumRows() / resultsPerPage;
	    int next         = Math.min(numPages, bean.getPage() + 1);

	    bean.setPage(next);

	} catch (AppException se) {
	    log.debug("", se);

	    request.setAttribute("error_message", se.getMessage());

	    nextPage = VIEW_SEARCH;
	} 
	    
	return nextPage;
    }


    /**
     * Dispatches <code>Previous</code> actions.
     */
    protected String dispatchPrevious(HttpServletRequest request, HttpServletResponse response)
        throws LoginException, AccessException {

        log.debug("***** dispatchPrevious *****");

        //next page if operation is successful
        String nextPage = VIEW_RESULTS;

        try {
	    //get session
	    HttpSession session = request.getSession();

	    //get searchBean from session, check for validity
	    ISearchBean bean = (ISearchBean)session.getAttribute(beanName);
	    if (bean == null || Utils.isEmpty(bean.getQuery()) ) throw new AppException(MSG_NO_SEARCH);

	    //set the next page
	    GridList results = getResults(bean);
	    int prev         = Math.max(0, bean.getPage() - 1);

	    bean.setPage(prev);

	} catch (AppException se) {
	    log.debug("", se);

	    request.setAttribute("error_message", se.getMessage());

	    nextPage = VIEW_SEARCH;
	}
	    
	return nextPage;
    }


    /**
     * Dispatches <code>page</code> actions.
     */
    protected String dispatchPage(HttpServletRequest request, HttpServletResponse response)
        throws LoginException, AccessException {

        log.debug("***** dispatchPage *****");

        //next page if operation is successful
        String nextPage = VIEW_RESULTS;

        try {
	    //get session
	    HttpSession session = request.getSession();

	    //get params from form
	    Integer page = convertInteger( request.getParameter("pageNum") );  //required
	    int setPage  = page.intValue();

	    //get searchBean from session, check for validity                                          
            ISearchBean bean = (ISearchBean)session.getAttribute(beanName);
            if (bean == null || Utils.isEmpty(bean.getQuery()) ) throw new AppException(MSG_NO_SEARCH);

	    //set the next page
	    GridList results = getResults(bean);
            int numPages     = results.getNumRows() / resultsPerPage;

	    if (0 <= setPage && setPage <= numPages) bean.setPage(setPage);

	} catch (AppException se) {
	    log.debug("", se);

	    request.setAttribute("error_message", se.getMessage());

	    nextPage = VIEW_SEARCH;
	}
	    
	return nextPage;
    }


    //UTILITY METHODS ------------------------------------------

    /**
     * Sets custom column names for downloadable reports. 
     * 
     * Custom column names stored in the columnNames hashmap will be applied if tha hashmap is not null.
     *
     * Override this method to implement report-specific actions.
     */
    protected void setColumnNames(ISearchBean bean, GridList results) {

	if (results == null) return;

	if (!Utils.isEmpty(columnNames)) results.setColumnNames(columnNames);
    }


    /**
     * Generates an ORDER BY clause based on the column headers clicked
     */
    protected String getOrderByClause(ISearchBean bean) {
	if (sortClauses == null) return "";

	String orderBy          = bean.getSortBy();
	String orderByClause    = (String)sortClauses.get( bean.getSortBy() );
	if (Utils.isEmpty(orderByClause)) orderByClause = (String)sortClauses.get("default");

	//determine ascending/descending                                                                                        
	String orderByDirection = bean.getSortAscending() ? " ASC" : " DESC";

	orderByClause = orderByClause.replaceAll("ASCDESC", orderByDirection);

	return Utils.noNulls(orderByClause);
    }


    /**                                                              
     * Returns the proper style to use for the clickable column header                                               
     */
    public static String getSortClass(SearchBean searchBean, String sortBy) {
        if (searchBean == null || Utils.isEmpty(sortBy)) return "";

        if ( sortBy.equals(searchBean.getSortBy()) ) return searchBean.getSortAscending() ? "sortAscending" : "sortDescending" ;

        return "";
    }


    /**                                                              
     * Generates the DataBean to be used for queries. Depends on the application 
     * init parameter "database" for JNDI connect String.
     */
    protected void generateDataBean(String jndiConnect) {
	log.debug("com.codemagi.servlets.search.SearchController: Generating DataSourceBean2 using: " + jndiConnect);
	
	dataBean = new DataSourceBean2( jndiConnect );
	
	log.debug("   -DONE");
    }
    
    
    /**
     * Gets the results of a query from cache (if available) or DB (if necessary).                                     
     * 
     * @param ibean       An ISearchBean to get the results for
     * @return GridList   GridList containing query results (as Strings)                            
     */
    public GridList getResults(ISearchBean ibean) throws AppException {

	//sanity check
	try {
	    if (ibean == null) ibean = (ISearchBean)Class.forName(beanClass).newInstance();
	} catch (Exception e) {
	    log.debug("Exception instantiating ISearchBean", e);
	    throw new AppException(MSG_UNKNOWN_ERROR);
	}

	//get the query to run
	String query = ibean.getQuery();
	if (Utils.isEmpty(query)) {
	    //this is the first instantiation of the ISearchBean, generate a default query
	    generateQuery(ibean);
	    
	    query = ibean.getQuery();
	}
	
	return getResults(query);
    }


    /**                                                                                                          
     * Gets the results of a query from cache (if available) or DB (if necessary).                             
     *                                                
     * @param query       SQL query to gte results for.                                                       
     * @return GridList   GridList containing query results (as Strings)                            
     */
    private GridList getResults(String query) throws AppException {
	
	log.debug("getResults() QUERY:\n" + query);

	if (Utils.isEmpty(query)) return new GridList();

	GridList results = (GridList)queryResults.get(query);

	//if a cache timeout has been set 
	if (cacheTimeout > -1) {
            //check if the cache has expired
            Long lastUpdate  = (Long)queryTimes.get(query);
            log.debug("SearchController.getResults(): cache lastUpdate: " + lastUpdate + " current: " + System.currentTimeMillis() );
            if (lastUpdate == null || (lastUpdate.longValue() + cacheTimeout < System.currentTimeMillis())) {
                log.debug("SearchController.getResults(): cache expired, updating table");
                results = generateResults(query);
            }
        }

        if (results == null) results = generateResults(query);

        return results;
    }


    private GridList generateResults(String query) {

	    log.debug("generateResults() query: *" + query + "*");

            dataBean.setQuery(query);
            dataBean.executeQuery();

	    GridList results = dataBean.getResults();
	    if (results == null) {
		results = new GridList();
		log.debug("RESULTS RETURNED NULL FROM dataBean");
	    }

	    log.debug("NUM RESULTS FROM QUERY: " + results.getNumRows());

            queryResults.put(query, results);
	    queryTimes.put(query, new Date().getTime());

	    return results;
    }
    

    /**
     * Clears the cache of query results. Call this method if there has been a major change in the data to be searched.
     */
    public void clearCache() {	
	queryResults = new ReferenceMap();
    }

    public int getResultsPerPage() {
	return resultsPerPage;
    }

    public int getPagesToDisplay() {
	return pagesToDisplay;
    }

}
